Day 28 我們把 Product API 的責任拆開:
ProductsController
→ 處理 HTTP Request / Response
ProductService
→ 處理 Product 操作
並透過 Dependency Injection,讓 Controller 可以取得需要的 Service。
接下來會遇到另一個問題:
如果每個 Controller 都需要處理相同的 Exception,難道每一支 Action 都要重複寫 try-catch 嗎?
例如:
try
{
// 執行程式
}
catch (Exception)
{
// 回傳錯誤
}
如果每個 Controller 都這樣寫:
ProductsController
→ try-catch
OrdersController
→ try-catch
UsersController
→ try-catch
很快就會出現大量重複程式碼。
ASP.NET Core 提供了一個更適合處理這種共用流程的機制:
Middleware
今天會從 Middleware 開始,最後實作:
Global Exception Handling
讓未處理的 Exception 可以在 Application 中統一處理。
當 Client 發送:
GET /api/products/1
Request 並不是直接進入 Controller。
它會經過一連串處理流程,這整條流程稱為:
Request Pipeline
→ 請求處理管線
而 Pipeline 裡每一個負責處理 Request / Response 的元件,就是:
Middleware
可以先把 Middleware 想成:
HTTP Request 前往 Controller 時會經過的一層層處理站。
常見的 Middleware 可能負責:
Exception Handling
HTTPS Redirection
Authentication
Authorization
Middleware 有一個很重要的特性:
Request
↓
Middleware A
↓
Middleware B
↓
Controller
↑
Middleware B
↑
Middleware A
↑
Response
也就是:
Middleware 可以在下一層執行前處理 Request,也可以在下一層執行完成後處理 Response。
因此 Middleware 可以包住後面的 Pipeline。
這也是 Global Exception Handling 能運作的重要原因。
app.Use...:把 Middleware 加進 Pipeline其實我們之前的 Program.cs 已經看過 Middleware:
app.UseHttpsRedirection();
可以先理解成:
app.Use...
→ 把 Middleware 加進 Request Pipeline
例如:
var app = builder.Build();
app.UseHttpsRedirection();
app.MapControllers();
app.Run();
今天會再加入:
app.UseExceptionHandler();
把 Exception Handling 加進 Pipeline。
try-catch?假設:
[HttpGet("{id:int}")]
public ActionResult<Product> GetById(int id)
{
try
{
Product? product =
_productService.GetById(id);
if (product is null)
{
return NotFound();
}
return Ok(product);
}
catch (Exception)
{
return StatusCode(500);
}
}
如果每個 Action 都這樣做:
GetAll()
→ try-catch
GetById()
→ try-catch
Create()
→ try-catch
Update()
→ try-catch
Delete()
→ try-catch
Controller 又會開始累積大量重複邏輯。
這些 Action 做的其實都是同一件事:
發生未處理 Exception
↓
回傳 HTTP 500
所以更好的責任分工是:
Controller
→ 處理正常流程
Global Exception Handler
→ 統一處理未處理 Exception
GlobalExceptionHandler今天會使用 ASP.NET Core 提供的:
IExceptionHandler
建立統一的 Exception Handler。
專案結構:
MyApi2
│
├─ Controllers
│ └─ ProductsController.cs
│
├─ Models
│ ├─ Product.cs
│ ├─ CreateProductRequest.cs
│ └─ UpdateProductRequest.cs
│
├─ Services
│ ├─ IProductService.cs
│ └─ ProductService.cs
│
├─ Exceptions
│ └─ GlobalExceptionHandler.cs
│
└─ Program.cs
建立:
Exceptions/GlobalExceptionHandler.cs
完整程式碼:
using Microsoft.AspNetCore.Diagnostics;
using Microsoft.AspNetCore.Mvc;
namespace MyApi2.Exceptions;
public class GlobalExceptionHandler
: IExceptionHandler
{
public async ValueTask<bool> TryHandleAsync(
HttpContext httpContext,
Exception exception,
CancellationToken cancellationToken
)
{
ProblemDetails problemDetails = new()
{
Status = StatusCodes.Status500InternalServerError,
Title = "Internal Server Error",
Detail = "伺服器發生未預期的錯誤。"
};
httpContext.Response.StatusCode =
StatusCodes.Status500InternalServerError;
await httpContext.Response.WriteAsJsonAsync(
problemDetails,
cancellationToken
);
return true;
}
}
今天先看懂四個重點:
IExceptionHandler
→ 接收未處理 Exception
ProblemDetails
→ 建立統一錯誤格式
TryHandleAsync()
→ 執行 Exception 處理
return true
→ 表示這個 Exception 已經被處理
也就是說:
TryHandleAsync()回傳true,代表目前這個 Handler 已經完成 Exception 的處理。
ProblemDetails 是什麼?剛才建立:
ProblemDetails problemDetails = new()
{
Status = StatusCodes.Status500InternalServerError,
Title = "Internal Server Error",
Detail = "伺服器發生未預期的錯誤。"
};
它可以讓 API Error Response 有一致的格式。
例如:
{
"title": "Internal Server Error",
"status": 500,
"detail": "伺服器發生未預期的錯誤。"
}
這比只回傳:
500
更容易讓 Client 理解發生了什麼事情。
Program.cs:註冊 Exception Handler接著修改:
Program.cs
完整程式碼:
using MyApi2.Exceptions;
using MyApi2.Services;
var builder =
WebApplication.CreateBuilder(args);
builder.Services.AddControllers();
builder.Services.AddScoped<
IProductService,
ProductService
>();
builder.Services.AddProblemDetails();
builder.Services.AddExceptionHandler<
GlobalExceptionHandler
>();
var app = builder.Build();
app.UseExceptionHandler();
app.UseHttpsRedirection();
app.MapControllers();
app.Run();
今天新增三個重要位置。
第一個:
builder.Services.AddProblemDetails();
註冊 Problem Details 相關服務。
第二個:
builder.Services.AddExceptionHandler<
GlobalExceptionHandler
>();
註冊我們建立的 GlobalExceptionHandler。
第三個:
app.UseExceptionHandler();
把 Exception Handler Middleware 加進:
Request Pipeline
整體可以理解成:
註冊 Handler
↓
加入 Middleware Pipeline
↓
發生未處理 Exception
↓
GlobalExceptionHandler 處理
為了觀察效果,可以暫時在:
ProductsController.cs
加入:
[HttpGet("error")]
public IActionResult TestError()
{
throw new InvalidOperationException(
"測試 Exception"
);
}
接著執行:
GET /api/products/error
Controller 會丟出:
InvalidOperationException
因為這個 Exception 沒有在 Controller 裡處理,所以會繼續往外傳。
最後 Client 會收到:
HTTP 500 Internal Server Error
以及類似:
{
"title": "Internal Server Error",
"status": 500,
"detail": "伺服器發生未預期的錯誤。"
}
Controller 不需要為這類未處理 Exception,在每一個 Action 重複撰寫 try-catch。
這就是 Global Exception Handling 的價值。
NotFound() 也是 Exception 嗎?不是。
之前我們寫:
Product? product =
_productService.GetById(id);
if (product is null)
{
return NotFound();
}
這裡的:
return NotFound();
只是 Controller 主動決定:
Product 不存在
↓
回傳 HTTP 404
它不是 Exception。
所以:
404 Not Found
≠
Exception
例如:
| 情況 | 處理方式 |
|---|---|
| Product 找不到 | NotFound() |
| Request 不合法 | Validation / BadRequest() |
| 建立成功 | CreatedAtAction() |
| 未處理 Exception | Global Exception Handler |
可以先這樣理解:
可預期的 HTTP 結果
→ Controller / ASP.NET Core
→ 回傳對應 HTTP Status
未處理 Exception
→ Global Exception Handler
例如 Day 27 學過的 Validation,在使用 [ApiController] 時,ASP.NET Core 可以直接回傳:
400 Bad Request
不一定需要 Controller 自己手動:
return BadRequest();
所以 Global Exception Handling 並不是把所有 HTTP Error 都變成 Exception。
它主要負責:
處理沒有被其他地方處理的 Exception。
目前:
app.UseExceptionHandler();
app.UseHttpsRedirection();
app.MapControllers();
可以理解成:
Request
↓
Exception Handler Middleware
↓
其他 Middleware
↓
Controller
Exception Handler 放在比較前面,就能包住後面的處理流程。
如果後面的 Controller 或 Service 發生未處理 Exception:
Request
↓
Exception Handler
↓
Controller / Service
↓
Exception ✖
↑
Exception Handler 處理
↓
Error Response
因此:
Middleware 是按照加入 Pipeline 的順序執行的。
順序不同,能處理到的範圍也可能不同。
今天只需要掌握四個步驟:
註冊 Exception Handler
↓
加入 Middleware Pipeline
↓
捕捉未處理 Exception
↓
統一產生 Error Response
正常情況:
HTTP Request
↓
Middleware
↓
Controller
↓
Service
↓
HTTP Response
如果發生未處理 Exception:
HTTP Request
↓
Exception Handler Middleware
↓
Controller / Service
↓
Exception
↓
GlobalExceptionHandler
↓
500 ProblemDetails
↓
HTTP Response
對應到程式碼,可以看三個位置。
第一個:
builder.Services.AddExceptionHandler<
GlobalExceptionHandler
>();
代表:
註冊
第二個:
app.UseExceptionHandler();
代表:
加入 Pipeline
第三個:
public async ValueTask<bool> TryHandleAsync(...)
代表:
統一處理 Exception
今天最重要的是理解:
Middleware 用來處理 Application 共用的 Request / Response 流程。
而:
Global Exception Handling 將未處理的 Exception 集中處理,避免相同的 try-catch 散落在各個 Controller。
最後記住一句話:
Middleware 解決的是「共用流程放在哪裡」;Global Exception Handling 解決的是「未處理 Exception 要在哪裡統一處理」。